14-2. 강화학습(4 x 4 Grid World q_table, Appedix B)

Author

이상민

Published

June 14, 2025

1. imports

import gymnasium as gym
#---#
import numpy as np
import collections
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
import IPython

2. q_table

q[s1,s2,a]는 상태 (s1,s2) 에서 행동 a의 “품질”을 의미한다.

- 직관적으로 푸는 방법 (손으로 푸는 다이나믹 프로그래밍)

A. 미래보상

- 언뜻생각하면 4x4 문제에서 q_table은 아래와 같이 생각하는게 합리적인듯 보인다.

  • q[s1,s2,a] = 상태 (s1,s2)에서 행동 a를 했을 경우 얻게되는 보상의 평균
  • \(q(s,a) = r(s,a) = \mathbb{E}[\text{Reward} | \text{State}=s, \text{Action}=a]\)

그렇지만 아래와 같이 생각하는게 더 합리적이다.

  • q[s1,s2,a] = 상태 (s1,s2)에서 행동 a를 했을 경우 얻게되는 보상의 평균 + 미래에 얻게되리라 기대하는 보상
  • \(q(s,a) = r(s,a) + r_{\text{future}}\)

단, 여기에서 미래에 얻게되리라 기대하는 보상은 최선의 선택을 한다는 전제하에 계산

- 미래에 얻게되리라 기대하는 보상은 어떻게 정의할 수 있을까?

# 예제1 – 상태 (2,2) 에서 “action=down” 을 했을때

  • 즉시 얻게되는 보상과
  • 미래에 얻게되리라 기대하는 보상

은 무엇인가? 이것을 바탕으로 \(s=(2,1)\), \(a=\text{down}\) 의 품질(Quality)는 어떻게 평가할 수 있는가?

(풀이?)

즉시 얻을 수 있다고 생각되는 보상은-1 이고 미래에 얻으리라 기대되는 보상은 100 점이다. 따라서 99점으로 평가하는게 합리적인듯하다. 수식으로 쓰면

\[q(s,a)=q(s_1,s_2,a)=q(2,2, \text{down}) = -1 + 100 = r(2,2,\text{down}) + \max_{a'}q(3,2,a')\]

와 같이 쓸 수 있겠다.

#

# 예제2 – 상태 (1,2) 에서 “action=down” 을 했을때

  • 즉시 얻게되는 보상과
  • 미래에 얻게되리라 기대하는 보상

은 무엇인가? 이것을 바탕으로 \(s=(1,2)\), \(a=\text{down}\) 의 품질(Quality)는 어떻게 평가할 수 있는가?

(풀이?)

즉시 얻을 수 있다고 생각되는 보상은-1 이고 미래에 얻으리라 기대되는 보상은 99점이다. 따라서 98점으로 평가하는게 합리적인듯하다. 수식으로 쓰면

\[q(s,a)=q(s_1,s_2,a)=q(1,2, \text{down}) = -1 + 99 = r(1,2,\text{down}) + \max_{a'}q(2,2,a')\]

와 같이 쓸 수 있겠다.

#

# 예제3 – 상태 (0,1) 에서 “action=right” 을 했을때

  • 즉시 얻게되는 보상과
  • 미래에 얻게되리라 기대하는 보상

은 무엇인가? 이것을 바탕으로 \(s=(0,1)\), \(a=\text{right}\) 의 품질(Quality)는 어떻게 평가할 수 있는가?

(풀이?)

앞의 예제들을 일반화하면 아래와 같은 수식을 쓸 수 있다.

\[q(0,1, \text{right}) = r(0,1,\text{right}) + \max_{a'}q(0,2,a')\]

따라서 만약에 \(\max_{a}q(0,2,a)\)의 값을 알고 있다면 이를 구할 수 있다.

#

- (아직 부족한) 깨달음: 모든 \((s,a)\)에 대하여 \(q(s,a)\)의 값은 아래와 같이 정의할 수 있겠다.

\[q(s,a) = r(s,a) + \max_{a'}q(s',a')\]

B. 감가율

# 예제1 – 당신은 지금 아무것도 쓰여 있지 않은 빈 종이 한 장을 가지고 있습니다. 이 종이에 쓸 수 있는 숫자는 오직 두 가지, 0 또는 1입니다. 어떤 숫자를 쓰느냐에 따라 보상이 달라지는데, 수많은 실험을 통해 0을 쓰면 아무 보상도 없고, 1을 쓰면 10만 원을 받을 수 있다는 사실이 확인되었습니다. 이 사실이 확인된 이후 이 빈 종이의 가치는 얼마일까요?

(1) 0원이다.

(2) 10만원이다.

(3) 5만원이다.

(4) 모르겠다.

#

# 예제2 – 당신 앞에는 빨간색 종이 한 장이 있습니다. 이 종이에는 0 또는 1 중 하나의 숫자를 쓸 수 있습니다. 만약 1을 쓰면 다음 단계인 주황색 종이 한 장을 받게 됩니다. 주황색 종이에도 똑같이 0 또는 1을 쓸 수 있고, 여기에 1을 쓰면 노란색 종이, 그다음은 초록색 종이, 그 다음은 파란색 종이, 그 다음은 남색 종이, 마지막으로는 보라색 종이를 순서대로 받습니다. 총 7단계(빨강 → 주황 → 노랑 → 초록 → 파랑 → 남색 → 보라색)를 거친 후, 보라색 종이에 1을 쓰면 비로소 10만 원의 현금 보상을 받을 수 있습니다. 단, 어느 단계에서든 0을 쓰면 아무 일도 일어나지 않고 그 즉시 게임이 종료됩니다. 즉, 그 이후로는 종이도 받을 수 없고 보상도 없습니다. 이 사실이 알려진 이후, 지금 당신이 들고 있는 ’빨간색 종이’의 가치는 얼마일까요?

(1) 0원이다.

(2) 10만 원이다.

(3) \(\frac{1}{2^6}\) x 10만원이다.

(4) 모르겠다.

#

- 직관: 아무리 보장된 보상이라고 해도, 미래에 주어지는 보상은 현재의 보상과 동급취급할 수 없다.

- 진짜 깨달음: 모든 \((s,a)\)에 대하여 \(q(s,a)\)의 값은 아래와 같이 정의하는게 합리적이다.

\[q(s,a) = r(s,a) + \gamma \max_{a'}q(s',a')\]

여기에서 \(\gamma\)는 0과 1사이의 값이며 감가율(discout factor)이라 부른다.

C. q_table update

- 지난시간코드

class GridWorld:
    def __init__(self):
        self.a2d = {
            0: np.array([0,1]), # →
            1: np.array([0,-1]), # ←  
            2: np.array([1,0]),  # ↓
            3: np.array([-1,0])  # ↑
        }
        self.state_space = gym.spaces.MultiDiscrete([4,4])
        self.state = np.array([0,0])
        self.reward = None
        self.terminated = False
    def step(self,action):
        self.state = self.state + self.a2d[action]
        s1,s2 = self.state
        if (s1==3) and (s2==3):
            self.reward = 100 
            self.terminated = True
        elif self.state in self.state_space:
            self.reward = -1 
            self.terminated = False
        else:
            self.reward = -10
            self.terminated = True
        # print(
        #     f"action = {action}\t"
        #     f"state = {self.state - self.a2d[action]} -> {self.state}\t"
        #     f"reward = {self.reward}\t"
        #     f"termiated = {self.terminated}"
        # )            
        return self.state, self.reward, self.terminated
    def reset(self):
        self.state = np.array([0,0])
        self.terminated = False
        return self.state
class RandomAgent:
    def __init__(self):
        self.state = np.array([0,0]) 
        self.action = None 
        self.reward = None 
        self.next_state = None
        self.terminated = None
        #---#
        self.states = collections.deque(maxlen=500000)
        self.actions = collections.deque(maxlen=500000)
        self.rewards = collections.deque(maxlen=500000)
        self.next_states = collections.deque(maxlen=500000)
        self.terminations = collections.deque(maxlen=500000)
        #---#
        self.action_space = gym.spaces.Discrete(4)
        self.n_experience = 0
    def act(self):
        self.action = self.action_space.sample()
    def save_experience(self):
        self.states.append(self.state)
        self.actions.append(self.action)
        self.rewards.append(self.reward)
        self.next_states.append(self.next_state)
        self.terminations.append(self.terminated)
        self.n_experience = self.n_experience + 1
    def learn(self):
        pass
player = RandomAgent()
env = GridWorld()
scores = [] 
score = 0 
#
for e in range(1,100000):
    #---에피소드시작---#
    while True:
        # step1 -- 액션선택
        player.act()
        # step2 -- 환경반응 
        player.next_state, player.reward, player.terminated = env.step(player.action)
        # step3 -- 경험기록 & 학습 
        player.save_experience()
        player.learn()
        # step4 --종료 조건 체크 & 후속 처리
        if env.terminated:
            score = score + player.reward
            scores.append(score)
            score = 0 
            player.state = env.reset() 
            break
        else: 
            score = score + player.reward         
            player.state = player.next_state

- 상황: player가 경험은 있는데, q_table을 만들줄 모름 (데이터는 있음, 학습이 안된상태)

player.n_experience
325309

- 저번시간에 배운 q_table

q_table = np.zeros((4,4,4))
memory = zip(player.states, player.actions, player.rewards)
for (s1,s2), a, r in memory:
    qhat = q_table[s1,s2,a] # 내가 생각했던갓
    q = r # 실제값
    diff = q-qhat # 차이
    q_table[s1,s2,a] = q_table[s1,s2,a]  + 0.01*diff# update
for a in [0,1,2,3]: 
    print(f"action = {a}")
    print(f"q[...,{a}] = {q_table[...,a].round(3)}")
    print("---")
action = 0
q[...,0] = [[ -1.     -1.     -1.    -10.   ]
 [ -1.     -1.     -1.    -10.   ]
 [ -1.     -1.     -1.     -9.999]
 [ -1.     -1.     99.993   0.   ]]
---
action = 1
q[...,1] = [[-10.  -1.  -1.  -1.]
 [-10.  -1.  -1.  -1.]
 [-10.  -1.  -1.  -1.]
 [-10.  -1.  -1.   0.]]
---
action = 2
q[...,2] = [[ -1.     -1.     -1.     -1.   ]
 [ -1.     -1.     -1.     -1.   ]
 [ -1.     -1.     -1.     99.991]
 [-10.    -10.     -9.999   0.   ]]
---
action = 3
q[...,3] = [[-10. -10. -10. -10.]
 [ -1.  -1.  -1.  -1.]
 [ -1.  -1.  -1.  -1.]
 [ -1.  -1.  -1.   0.]]
---

- 이번시간에 배운 q_table: 아래를 이용한다.

\[q(s,a) = r(s,a) + \gamma \max_{a'}q(s',a')\]

Note

사실 좀 더 실용적으로는(=코딩친화적으로는) 아래의 수식을 쓰는게 좋다.

\[q(s,a) = \begin{cases} r(s,a) & \text{if terminated} \\ r(s,a) + \gamma \max_{a'}q(s',a') & \text{else} \end{cases}\]

q_table = np.zeros((4,4,4))
memory = zip(player.states, player.actions, player.rewards, player.next_states, player.terminations)
for (s1,s2), a, r, (ss1,ss2), tmd  in memory:
    qhat = q_table[s1,s2,a] # 내가 생각했던값
    if tmd: 
        q = r # 실제값
    else: 
        future = q_table[ss1,ss2,:].max()
        q = r + 0.95 * future
    diff = q-qhat # 차이
    q_table[s1,s2,a] = q_table[s1,s2,a]  + 0.01*diff# update
for a in [0,1,2,3]: 
    print(f"action = {a}")
    print(f"q[...,{a}] = {q_table[...,a].round(3)}")
    print("---")
action = 0
q[...,0] = [[ 72.837  77.719  82.751 -10.   ]
 [ 77.724  82.869  88.26  -10.   ]
 [ 82.866  88.285  93.986  -9.999]
 [ 88.233  93.981  99.993   0.   ]]
---
action = 1
q[...,1] = [[-10.     68.193  72.83   77.663]
 [-10.     72.834  77.718  82.84 ]
 [-10.     77.716  82.861  88.155]
 [-10.     82.717  88.132   0.   ]]
---
action = 2
q[...,2] = [[ 72.837  77.724  82.864  88.191]
 [ 77.72   82.869  88.285  93.97 ]
 [ 82.801  88.274  93.989  99.991]
 [-10.    -10.     -9.999   0.   ]]
---
action = 3
q[...,3] = [[-10.    -10.    -10.    -10.   ]
 [ 68.193  72.834  77.713  82.601]
 [ 72.831  77.718  82.858  88.051]
 [ 77.679  82.839  88.196   0.   ]]
---
player.q_table = q_table
def act(player,s1,s2):
    action = player.q_table[s1,s2,:].argmax()
    return action
act(player,0,0)
2
player.q_table[0,0,:]
array([ 72.83682203, -10.        ,  72.83698221, -10.        ])

4. Solve

act(player,)

Appendix B - 신경망관련용어

- 은근히 용어가 헷갈리는데, 뜻을 좀 살펴보자.

  • ANN: 인공신경망
  • MLP: 다층퍼셉트론 (레이어가 여러개 있어요)
  • DNN: 깊은신경망, 심층신경망
  • CNN: 합성곱신경망
  • RNN: 순환신경망

# 예시1 – MLP, DNN

net = torch.nn.Sequential(
    torch.nn.Linear(in_features=1,out_features=2),
    torch.nn.ReLU(),
    torch.nn.Linear(in_features=2,out_features=2),
    torch.nn.ReLU(),
    torch.nn.Linear(in_features=2,out_features=1),    
    torch.nn.Sigmoid()
)
  • ANN: O
  • MLP: O
  • DNN: O
  • CNN: X (합성곱레이어가 없으므로)
  • RNN: X (순환구조가 없으므로)

#

# 예시2 – MLP, Shallow Network

net = torch.nn.Sequential(
    torch.nn.Linear(in_features=1,out_features=2),
    torch.nn.ReLU(),
    torch.nn.Linear(in_features=2,out_features=1),
    torch.nn.Sigmoid()
)
  • ANN: O
  • MLP: O
  • DNN: X (깊은 신경망으로 생각하려면 더 많은 레이어가 필요함. 합의된 기준은 히든레이어 2장이상, 이걸 설명하기 위해서 얕은 신경망이란 용어도 씀)
  • CNN: X (합성곱레이어가 없으므로)
  • RNN: X (순환구조가 없으므로)

#

# 예시3 – MLP, DNN, Wide NN

net = torch.nn.Sequential(
    torch.nn.Linear(in_features=1,out_features=1048576),
    torch.nn.ReLU(),
    torch.nn.Linear(in_features=1048576,out_features=1048576),
    torch.nn.ReLU(),
    torch.nn.Linear(in_features=1048576,out_features=1),
    torch.nn.Sigmoid(),    
)
  • ANN: O
  • MLP: O
  • DNN: O (깊긴한데 이정도면 모양이 깊다기 보다는 넓은 신경망임, 그래서 어떤 연구에서는 이걸 넓은 신경망이라 부르기도 함)
  • CNN: X (합성곱레이어가 없으므로)
  • RNN: X (순환구조가 없으므로)

# 예시4 – CNN

net = torch.nn.Sequential(
    # Layer1
    torch.nn.Conv2d(1, 64, kernel_size=4, stride=2, padding=1, bias=False),
    torch.nn.LeakyReLU(0.2),
    # Layer2
    torch.nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1, bias=False),
    torch.nn.BatchNorm2d(128),
    torch.nn.LeakyReLU(0.2),
    # Layer3
    torch.nn.Conv2d(128, 256, kernel_size=4, stride=2, padding=1, bias=False),
    torch.nn.BatchNorm2d(256),
    torch.nn.LeakyReLU(0.2),
    # Layer4
    torch.nn.Conv2d(256, 512, kernel_size=4, stride=2, padding=1, bias=False),
    torch.nn.BatchNorm2d(512),
    torch.nn.LeakyReLU(0.2),
    # Layer5
    torch.nn.Conv2d(512, 1, kernel_size=4, stride=1, padding=0, bias=False),
    torch.nn.Sigmoid(),
    torch.nn.Flatten()
)
  • ANN: O
  • MLP: X (합성곱연결이 포함되어있으므로, MLP가 아님, 완전연결만 포함해야 MLP임)
  • DNN: O
  • CNN: O (합성곱레이어를 포함하고 있으므로)
  • RNN: X (순환구조가 없으므로)

#

# 예시5 – CNN

net = torch.nn.Sequential(
    torch.nn.Conv2d(1,16,(5,5)),
    torch.nn.ReLU(),
    torch.nn.MaxPool2d((2,2)),
    torch.nn.Flatten(),
    torch.nn.Linear(2304,1),
    torch.nn.Sigmoid()
)
  • ANN: O
  • MLP: X
  • DNN: X? (히든레이어가 1장이므로..)
  • CNN: O (합성곱레이어를 포함하고 있으므로)
  • RNN: X (순환구조가 없으므로)

근데 대부분의 문서에서는 CNN, RNN은 DNN의 한 종류로 설명하고 있어서요.. 이런 네트워크에서는 개념충돌이 옵니다.

#

# 예시6 – RNN

class Net(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.rnn = torch.nn.RNN(4,2)
        self.linr = torch.nn.Linear(2,2) 
    def forward(self,X):
        h,_ = self.rnn(X) 
        netout = self.linr(h)
        return netout 
net = Net()     
  • ANN: O
  • MLP: X
  • DNN: X? (히든레이어가 1장이므로..)
  • CNN: X (합성곱레이어가 없으므로)
  • RNN: O

이것도 비슷한 개념충돌